export type EventType = string | symbol

// An event handler can take an optional event argument
// and should not return a value
export type Handler<T = any> = (event?: T) => void
export type WildcardHandler = (event: any, type?: EventType) => void

// An array of all currently registered event handlers for a type
export type EventHandlerList = Array<Handler>
export type WildCardEventHandlerList = Array<WildcardHandler>

// A map of event types and their corresponding event handlers.
export type EventHandlerMap = Map<
  EventType,
  EventHandlerList | WildCardEventHandlerList
>

export interface Emitter {
  // all: EventHandlerMap

  on<T = any> (type: EventType, handler: Handler<T>): void
  on (type: '*', handler: WildcardHandler): void

  off<T = any> (type: EventType, handler: Handler<T>): void
  off (type: '*', handler: WildcardHandler): void

  emit<T = any> (type: EventType, event?: T): void
  emit (type: '*', event?: any): void
}

class Base implements Emitter {
  private readonly all: EventHandlerMap = new Map()
  on (type: EventType, handler: WildcardHandler): void {
    const handlers = this.all.get(type)
    const added = handlers && handlers.push(handler)
    if (!added) {
      this.all.set(type, [handler])
    }
  }

  once (type: EventType, handler: WildcardHandler): void {
    const wrappedHandler: WildcardHandler = (event: any) => {
      this.off(type, wrappedHandler)
      handler(event, type)
    }
    const handlers = this.all.get(type)
    const added = handlers && handlers.push(wrappedHandler)
    if (!added) {
      this.all.set(type, [wrappedHandler])
    }
  }

  off (type: EventType, handler: WildcardHandler): void {
    const handlers = this.all.get(type)
    if (handlers) {
      handlers.splice(handlers.indexOf(handler) >>> 0, 1)
    }
  }

  emit (type: EventType, event?: any): void {
    ; ((this.all.get(type) || []) as WildCardEventHandlerList)
      .slice()
      .forEach((handler) => {
        handler(event, type)
      })
      ; ((this.all.get('*') || []) as WildCardEventHandlerList)
        .slice()
        .forEach((handler) => {
          handler(event, type)
        })
  }
}

export interface styleOptions {
  stroke?: string
  strokeWidth?: number
  fill?: string
}

export interface PainterOptions {
  container?: HTMLElement
  type?: string
}

export interface PathOptions {
  start: Point
  end?: Point
  points?: Array<Point>
}

interface Point {
  x: number
  y: number
}

interface Control {
  type: string
  value?: any
  painting: (point: Point) => void
  draw: (stageRect: HTMLCanvasElement) => void
  toJson: () => TextJson | ArrowJson | RectJson | PathJson
}

type ControlType = 'path' | 'arrow' | 'rect' | 'text'

type PathJson = {
  w: number
  c: string
  t: ControlType
  xs: number[]
  ys: number[]
}

type ArrowJson = {
  w: number
  c: string
  t: ControlType
  x1: number
  x2: number
  y1: number
  y2: number
}

type RectJson = ArrowJson

type TextJson = {
  c: string
  t: ControlType
  x: number
  y: number
  v: string
}

function getScaleRate (stage: HTMLElement) {
  return stage.clientWidth / stage.getBoundingClientRect().width || 1
}

class Path implements Control {
  type: ControlType = 'path'
  private stroke = '#000'
  private strokeWidth = 2
  private fill = 'none'
  private start: Point
  private points: Array<Point>
  constructor(options: PathOptions & styleOptions) {
    this.start = options.start
    this.stroke = options.stroke || this.stroke
    this.strokeWidth = options.strokeWidth || this.strokeWidth
    this.fill = options.fill || this.fill
    this.points = options.points || []
  }
  painting (point: Point) {
    this.points.push(point)
  }
  draw (stage: HTMLCanvasElement) {
    const ctx = stage.getContext('2d')!
    ctx.strokeStyle = this.stroke
    ctx.lineWidth = this.strokeWidth * getScaleRate(stage)
    ctx.beginPath()
    ctx.moveTo(this.start.x * stage.width, this.start.y * stage.height)
    this.points.forEach((p) => {
      ctx.lineTo(p.x * stage.width, p.y * stage.height)
    })
    ctx.stroke()
  }
  toJson () {
    return {
      w: this.strokeWidth,
      c: this.stroke,
      t: this.type,
      xs: [this.start.x, ...this.points.map((i) => i.x)],
      ys: [this.start.y, ...this.points.map((i) => i.y)],
    }
  }
}

class Arrow implements Control {
  type: ControlType = 'arrow'
  private stroke = '#000'
  private strokeWidth = 2
  private fill = 'none'
  private start: Point
  private end: Point
  constructor(options: PathOptions & styleOptions) {
    this.start = options.start
    this.end = options.end || options.start
    this.stroke = options.stroke || this.stroke
    this.strokeWidth = options.strokeWidth || this.strokeWidth
    this.fill = options.fill || this.fill
  }
  painting (point: Point) {
    this.end = point
  }
  draw (stage: HTMLCanvasElement) {
    var t = this.start.x * stage.width,
      a = this.start.y * stage.height,
      n = this.end.x * stage.width,
      r = this.end.y * stage.height,
      i = function (e: number, t: number, a: number, n: number, r: number) {
        return {
          x: t + e * (n - t),
          y: a + e * (r - a),
        }
      },
      o = function (e: number, t: number, a: number, n: number, r: number) {
        return {
          x: (n - t) * Math.cos(e) - (r - a) * Math.sin(e) + t,
          y: (n - t) * Math.sin(e) + (r - a) * Math.cos(e) + a,
        }
      },
      c = i(0.8, t, a, n, r),
      s = c.x,
      l = c.y,
      u = o(Math.PI / 2, s, l, n, r),
      d = u.x,
      p = u.y,
      m = o((Math.PI / 2) * 3, s, l, n, r),
      f = m.x,
      h = m.y,
      b = i(0.4, d, p, f, h),
      g = b.x,
      v = b.y,
      y = i(0.6, d, p, f, h),
      O = y.x,
      E = y.y,
      j = i(0.3, d, p, f, h),
      x = j.x,
      k = j.y,
      w = i(0.7, d, p, f, h),
      C = w.x,
      _ = w.y
    const ctx = stage.getContext('2d')!
    ctx.fillStyle = this.stroke
    ctx.strokeStyle = this.stroke
    ctx.lineWidth = this.strokeWidth
    ctx.fillStyle = this.fill
    ctx.beginPath()
    ctx.moveTo(t, a) // start point
    ctx.lineTo(g, v)
    ctx.lineTo(x, k)
    ctx.lineTo(n, r)
    ctx.lineTo(C, _)
    ctx.lineTo(O, E)
    ctx.fill()
    ctx.stroke()
  }
  toJson () {
    return {
      w: this.strokeWidth,
      c: this.stroke,
      t: this.type,
      x1: this.start.x,
      x2: this.end.x,
      y1: this.start.y,
      y2: this.end.y,
    }
  }
}

class Rect implements Control {
  private stroke = '#000'
  type: ControlType = 'rect'
  private strokeWidth = 2
  private fill = 'none'
  private start: Point
  private end: Point
  constructor(options: PathOptions & styleOptions) {
    this.start = options.start
    this.end = options.end || options.start
    this.stroke = options.stroke || this.stroke
    this.strokeWidth = options.strokeWidth || this.strokeWidth
    this.fill = options.fill || this.fill
  }
  painting (point: Point) {
    this.end = point
  }
  draw (stage: HTMLCanvasElement) {
    const ctx = stage.getContext('2d')!
    ctx.strokeStyle = this.stroke
    ctx.lineWidth = this.strokeWidth * getScaleRate(stage)
    ctx.strokeRect(
      stage.width * Math.min(this.start.x, this.end.x),
      stage.height * Math.min(this.start.y, this.end.y),
      stage.width * Math.abs(this.start.x - this.end.x),
      stage.height * Math.abs(this.start.y - this.end.y)
    )
  }
  toJson () {
    return {
      w: this.strokeWidth,
      c: this.stroke,
      t: this.type,
      x1: this.start.x,
      x2: this.end.x,
      y1: this.start.y,
      y2: this.end.y,
    }
  }
}

class Text implements Control {
  private stroke = '#000'
  type: ControlType = 'text'
  private fill = '#fff'
  value = ''
  private start: Point
  constructor(options: PathOptions & styleOptions & { value?: string }) {
    this.start = options.start
    this.stroke = options.stroke || this.stroke
    this.fill = options.fill || this.fill
    this.value = options.value || this.value
  }
  painting (point: Point) {
    this.start = point
  }
  draw (stage: HTMLCanvasElement) {
    if (this.value) {
      const scaleRate = getScaleRate(stage)
      const ctx = stage.getContext('2d')!
      ctx.strokeStyle = this.stroke
      ctx.fillStyle = this.fill
      ctx.font = `${20 * scaleRate}px 微软雅黑`
      let baseX = (stage.width * this.start.x + 1 * scaleRate)
      const baseY = (stage.height * this.start.y + 23 * scaleRate)

      const valueArr = this.value.split('\n')
      let lineNumber = 0
      valueArr.forEach((value) => {
        // 未处理换行
        let lineWidth = 0
        let word
        const wordArr = value.split('')
        while ((word = wordArr.shift())) {
          let newLineWidth = (lineWidth + ctx.measureText(word).width)
          if (newLineWidth > 200 * scaleRate) {
            lineNumber++
            lineWidth = 0
            ctx.fillText(word, baseX + lineWidth, baseY + lineNumber * 28 * scaleRate)
          } else {
            ctx.fillText(word, baseX + lineWidth, baseY + lineNumber * 28 * scaleRate)
            lineWidth = newLineWidth
          }
        }
        lineNumber++
      })
    }
  }
  toJson () {
    return {
      c: this.stroke,
      t: this.type,
      x: this.start.x,
      y: this.start.y,
      v: this.value,
    }
  }
}

class TextEditor extends Base {
  el: HTMLDivElement
  callback = (val: string) => { }
  input: HTMLDivElement
  constructor(options: styleOptions) {
    super()
    this.el = document.createElement('div')
    this.el.setAttribute(
      'style',
      'height: 100%; width: 100%; position: absolute;top:0;display:none;'
    )
    this.input = document.createElement('div')
    this.input.setAttribute('contenteditable', 'true')
    this.input.setAttribute(
      'style',
      `border: .0625rem solid red; outline: none;min-width: .625rem; word-wrap: break-word;word-break: break-all;-webkit-user-select: auto;z-index: 100;position: absolute;background: none;font-size: 20px;font-family: 微软雅黑;color: ${options.stroke || 'rgb(241, 62, 73)'
      };width: 200px;line-height:28px;`
    )
    this.el.appendChild(this.input)
    this.input.onmousedown = (e) => e.stopPropagation()
    this.input.onmousemove = (e) => e.stopPropagation()
    this.input.onmouseup = (e) => e.stopPropagation()

    this.input.onblur = (e) => this.onBlur(e)
    this.input.onkeyup = (e) => this.onKeyup(e)
  }
  ative (e: MouseEvent, callback: (val: string) => void) {
    if (this.input.innerText) {
      this.callback(this.input.innerText)
    }
    this.callback = callback
    this.input.style.left = `${e.offsetX}px`
    this.input.style.top = `${e.offsetY}px`
    this.el.style.display = 'block'
    this.input.style.transform = `scale(${getScaleRate(this.el)})`
    this.input.style.transformOrigin = 'top left'
    this.input.focus()
    this.input.innerText = ''
  }
  setStyle (options: styleOptions) {
    this.input.style.color = options.stroke || this.input.style.color
    this.input.style.border = options.stroke
      ? `.0625rem solid ${options.stroke}`
      : this.input.style.color
  }
  onBlur (e: FocusEvent) {
    this.el.style.display = 'none'
    this.callback(this.input.innerText)
  }
  onKeyup (e: KeyboardEvent) {
    this.emit('keyup', e)
  }
}

export class Painter extends Base {
  private _textEditor: TextEditor
  private _el: HTMLCanvasElement
  private _bg: HTMLCanvasElement
  private _rafId = -1
  private stroke = '#000'
  private strokeWidth = 2
  private fill = 'none'
  private type = 'path'
  private currentControl: Control | null
  private controls: Array<Control>
  private history: Array<Control>
  constructor(options: PainterOptions & styleOptions) {
    super()
    this._textEditor = new TextEditor(options)
    this._textEditor.el.addEventListener(
      'mousedown',
      this.startPainting.bind(this)
    )
    this.currentControl = null
    this.controls = []
    this.history = []
    this.setStyle(options)
    this.setPainter(options)
    this._el = document.createElement('canvas')
    this._bg = document.createElement('canvas')
    this._el.setAttribute('style', 'position: absolute;left:0;top:0;')
    this._bg.setAttribute('style', 'position: absolute;left:0;top:0;')

    options.container && this.mount(options.container)
  }
  mount (container: HTMLElement) {
    container.addEventListener('mousedown', this.startPainting.bind(this))
    this.painting = this.painting.bind(this)
    this.stopPainting = this.stopPainting.bind(this)
    container.appendChild(this._bg)
    container.appendChild(this._el)
    const rect = container.getBoundingClientRect()
    this._el.setAttribute('width', rect.width.toString())
    this._el.setAttribute('height', rect.height.toString())
    this._bg.setAttribute('width', rect.width.toString())
    this._bg.setAttribute('height', rect.height.toString())
    container.appendChild(this._el)
    container.appendChild(this._textEditor.el)
    const resizeObserver = new ResizeObserver((entries) => {
      this._el.setAttribute('width', container.clientWidth.toString())
      this._el.setAttribute('height', container.clientHeight.toString())
      this._bg.setAttribute('width', container.clientWidth.toString())
      this._bg.setAttribute('height', container.clientHeight.toString())
      this.draw()
    })
    resizeObserver.observe(container)
  }
  destory () {
    this._el.parentElement?.removeChild(this._el)
    this._el.parentElement?.removeChild(this._textEditor.el)
  }
  startPainting (e: MouseEvent) {
    document.addEventListener('mousemove', this.painting)
    document.addEventListener('mouseup', this.stopPainting)
    // this.clearBgStage()
    // this.draw()
    let rect = this._el.getBoundingClientRect()
    let startPoint = {
      x: (e.pageX - rect.x) / rect.width,
      y: (e.pageY - rect.y) / rect.height,
    }
    let options = {
      stroke: this.stroke,
      strokeWidth: this.strokeWidth,
      fill: this.fill,
      start: startPoint,
    }
    if (this.type === 'rect') {
      this.currentControl = new Rect(options)
    } else if (this.type === 'arrow') {
      options.fill = this.stroke
      this.currentControl = new Arrow(options)
    } else if (this.type === 'text') {
      options.fill = this.stroke
      this.currentControl = new Text(options)
    } else {
      this.currentControl = new Path(options)
    }

    this.history = []
    this.emit('paintStart', this.currentControl)
  }
  realPaintinge (e: MouseEvent) {
    let rect = this._el.getBoundingClientRect()
    let point = {
      x: (e.pageX - rect.x) / rect.width,
      y: (e.pageY - rect.y) / rect.height,
    }
    this.currentControl?.painting(point)
    this.clearStage()
    this.currentControl?.draw(this._el)
    this.emit('painting', this.currentControl)
  }

  painting (e: MouseEvent) {
    cancelAnimationFrame(this._rafId)
    this._rafId = requestAnimationFrame(() => this.realPaintinge(e))
  }
  stopPainting (e: MouseEvent) {
    cancelAnimationFrame(this._rafId)
    this.emit('painted', this.currentControl)

    if (this.currentControl!.type === 'text') {
      const control = this.currentControl
      this._textEditor.ative(e, (val: string) => {
        if (val) {
          control!.value = val
          control!.draw(this._el)
          this.controls.push(control!)
          this.emit('change', this.controls)
        }
      }) // 还未结束
    } else {
      this.controls.push(this.currentControl!)
      this.emit('change', this.controls)
      this.currentControl = null
    }
    this.clearBgStage()
    this.clearStage()
    this.draw()
    document.removeEventListener('mousemove', this.painting)
    document.removeEventListener('mouseup', this.stopPainting)
  }
  clearStage () {
    const ctx = this._el.getContext('2d')!
    ctx.clearRect(0, 0, this._el.width, this._el.height)
  }
  clearBgStage () {
    const ctx = this._bg.getContext('2d')!
    ctx.clearRect(0, 0, this._bg.width, this._bg.height)
  }
  clear () {
    this.controls = []
    this.clearStage()
    this.clearBgStage()
    this.emit('clear', this.controls)
    this.emit('change', this.controls)
  }
  undo () {
    if (this.controls.length) {
      let p = this.controls.pop()
      this.history.push(p!)
      this.clearBgStage()
      this.draw()
      this.emit('undo', p)
      this.emit('change', this.controls)
    }
  }
  redo () {
    if (this.history.length) {
      let p = this.history.pop()
      this.controls.push(p!)
      this.clearBgStage()
      this.draw()
      this.emit('redo', p)
      this.emit('change', this.controls)
    }
  }
  draw () {
    this.controls.forEach((i) => i.draw(this._bg))
  }
  setStyle (options: styleOptions) {
    this.stroke = options?.stroke || this.stroke
    this.strokeWidth = options?.strokeWidth || this.strokeWidth
    this.fill = options?.fill || this.fill
    this._textEditor.setStyle(options)
  }
  setPainter (options: PainterOptions) {
    this.type = options.type || this.type
  }
  toImage (type: string): string {
    return this._bg.toDataURL(`image/${type}`)
  }
  toJson () {
    return this.controls.map((i) => i.toJson())
  }
  loadJson (json: (TextJson | ArrowJson | PathJson)[]) {
    try {
      this.clear()
      this.controls = json.map((i) => {
        switch (i.t) {
          case 'path':
            return new Path({
              stroke: i.c,
              strokeWidth: (i as PathJson).w,
              fill: i.c,
              start: { x: (i as PathJson).xs[0], y: (i as PathJson).ys[0] },
              points: (i as PathJson).xs.slice(1).map((x, idx) => {
                return {
                  x: x,
                  y: (i as PathJson).ys[idx + 1],
                }
              }),
            })
          case 'arrow':
            return new Arrow({
              stroke: i.c,
              strokeWidth: (i as ArrowJson).w,
              fill: (i as ArrowJson).c,
              start: {
                x: (i as ArrowJson).x1,
                y: (i as ArrowJson).y1,
              },
              end: {
                x: (i as ArrowJson).x2,
                y: (i as ArrowJson).y2,
              },
            })
          case 'rect':
            return new Rect({
              stroke: i.c,
              strokeWidth: (i as ArrowJson).w,
              fill: (i as ArrowJson).c,
              start: {
                x: (i as ArrowJson).x1,
                y: (i as ArrowJson).y1,
              },
              end: {
                x: (i as ArrowJson).x2,
                y: (i as ArrowJson).y2,
              },
            })
          case 'text':
            return new Text({
              stroke: i.c,
              fill: (i as TextJson).c,
              start: {
                x: (i as TextJson).x,
                y: (i as TextJson).y,
              },
              value: (i as TextJson).v,
            })
        }
      })
      this.draw()
      this.emit('change', this.controls)
    } catch (e) {
      console.log('load json failed', e)
    }
  }
}
